Fix ClaudeSDKClient.receive_response() returning before background agent work finishes - #1224
Conversation
…ent work finishes receive_response() stopped on the first ResultMessage it saw, but a result frame only marks the end of one turn, not necessarily the run: when a delegated background agent/workflow task is still in flight, the CLI emits that result to close out the current turn and then continues with a follow-up turn once the task completes, ending in a second, later ResultMessage. receive_response() had no way to tell the two apart, so it returned on the first (intermediate) one and silently missed everything from the follow-up turn, including the real final result (anthropics#1138). Query already tracks in-flight delegated agent tasks via _track_task_lifecycle()/_inflight_tasks (added for anthropics#1088, to avoid closing stdin too early), so this reuses that same signal: each "result" frame sent while a delegated task is in flight has its uuid recorded, and Query.is_deferred_result() lets receive_response() recognize such a frame and keep reading instead of returning early. Fixes anthropics#1138.
sylvesterkaczmarek
left a comment
There was a problem hiding this comment.
_deferred_result_ids is only cleared by receive_response() via is_deferred_result(). Callers using the public lower-level receive_messages() API never consume those markers, so every intermediate background-task result UUID remains in the set for the lifetime of the Query. Could the deferred marker be retired when the message is delivered, or otherwise bounded independently of which receive API the caller uses?
is_deferred_result() is the only thing that retires an entry from _deferred_result_ids, and only ClaudeSDKClient.receive_response() calls it. A caller reading raw frames via receive_messages() instead never consults it, so a long-lived Query doing many delegated background-agent turns would accumulate one entry per deferred result for its whole lifetime. Cap the set at 128 entries with FIFO eviction, so it self-bounds regardless of which receive API the caller uses. The real fix for a given caller is still calling is_deferred_result() (i.e. using receive_response()); this is a backstop, not a behavior change for existing receive_response() callers.
|
@sylvesterkaczmarek Good catch — you're right, |
sylvesterkaczmarek
left a comment
There was a problem hiding this comment.
The deferred-result bookkeeping is now independently bounded for callers that consume only receive_messages(). The cap is above the 100-message stream buffer, so a marker that is still queued for receive_response() cannot be evicted by unread frames before the consumer sees it. The raw-message regression also exercises the unbounded-growth case I raised. My concern is resolved.
Fixes #1138
ClaudeSDKClient.receive_response()stopped on the firstResultMessageit saw, but aresultframe only marks the end of one turn, not necessarily the run: when a delegated background agent/workflow task (local_agent/local_workflow) is still in flight, the CLI emits that result to close out the current turn, then continues with a follow-up turn once the task completes, ending in a second, laterResultMessage.receive_response()had no way to tell the two apart, so it returned on the first (intermediate) one and silently missed everything from the follow-up turn — including the real final result.Queryalready tracks in-flight delegated agent tasks via_track_task_lifecycle()/_inflight_tasks(added for #1088, to avoid closing stdin too early). This reuses that same signal: eachresultframe sent while a delegated task is in flight has itsuuidrecorded, andQuery.is_deferred_result()letsreceive_response()recognize such a frame and keep reading instead of returning early.Adds a regression test (
test_receive_response_waits_for_deferred_result) reproducing the exact turn-boundary scenario: an intermediate result while a background agent task is in flight, followed by the task completing and a follow-up turn ending in the real final result.Test plan
python -m ruff check src/ tests/ scripts/— cleanpython -m mypy src/ scripts/— cleanpython -m pytest tests/— 1402 passed, 14 skippedGenerated by Claude Code